#!/usr/bin/env ruby
# == Synopsis
#
# Test individual client performance.  Can compile configurations, describe
# files, or retrieve files.
#
# = Usage
#
#   puppet-test  [-c|--compile] [-D|--describe <file>] [-d|--debug]
#       [--fork <num>] [-h|--help] [-H|--hostname <host name>] [-l|--list] [-r|--repeat <number=1>]
#       [-R|--retrieve <file>] [-t|--test <test>] [-V|--version] [-v|--verbose]
#
# = Description
#
# This is a simple script meant for doing performance tests with Puppet.  By
# default it pulls down a compiled configuration, but it supports multiple
# other tests.
#
# = Options
#
# Note that any setting that's valid in the configuration file
# is also a valid long argument.  For example, 'server' is a valid configuration
# parameter, so you can specify '--server <servername>' as an argument.
#
# See the configuration file documentation at
# http://reductivelabs.com/projects/puppet/reference/configref.html for
# the full list of acceptable parameters. A commented list of all
# configuration $options can also be generated by running puppetd with
# '--genconfig'.
#
# compile::
#   Compile the client's configuration.  The default.
#
# debug::
#   Enable full debugging.
#
# describe::
#   Describe the file being tested.  This is a query to get information about
#   the file from the server, to determine if it should be copied, and is the
#   first half of every file transfer.
#
# fork::
#   Fork the specified number of times, thus acting as multiple clients.
#
# fqdn::
#   Set the fully-qualified domain name of the client.  This is only used for
#   certificate purposes, but can be used to override the discovered hostname.
#   If you need to use this flag, it is generally an indication of a setup problem.
#
# help::
#   Print this help message
#
# list::
#   List all available tests.
#
# node::
#  Specify the node to use.  This is useful for looking up cached yaml data
#  in your :clientyaml directory, and forcing a specific host's configuration to
#  get compiled.
#
# pause::
#  Pause before starting test (useful for testing with dtrace).
#
# repeat::
#  How many times to perform the test.
#
# retrieve::
#   Test file retrieval performance.  Retrieves the specified file from the
#   remote system.  Note that the server should be specified via --server,
#   so the argument to this option is just the remote module name and path,
#   e.g., "/dist/apps/myapp/myfile", where "dist" is the module and
#   "apps/myapp/myfile" is the path to the file relative to the module.
#
# test::
#   Specify the test to run.  You can see the list of tests by running this command with --list.
#
# verbose::
#   Turn on verbose reporting.
#
# version::
#   Print the puppet version number and exit.
#
# = Example
#
#   puppet-test --retrieve /module/path/to/file
#
# = License
#   Copyright 2011 Luke Kanies
#
#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
#       http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.

# Do an initial trap, so that cancels don't get a stack trace.
trap(:INT) do
    $stderr.puts "Cancelling startup"
    exit(1)
end

require 'puppet'
require 'puppet/network/client'
require 'getoptlong'

class Suite
    attr_reader :name, :doc

    @@suites = {}
    @@tests = {}

    def self.[](name)
        @@suites[name]
    end

    # Run a test by first finding the suite then running the appropriate test.
    def self.run(test)
        unless suite_name = @@tests[test]
            raise "Unknown test %s" % test

        end
        unless suite = @@suites[suite_name]
            raise "Unknown test suite %s from test %s" % [suite_name, test]
        end

        suite.run(test)
    end

    # What suites are available?
    def self.suites
        @@suites.keys
    end

    def forked?
        defined? @forking
    end

    # Create a new test suite.
    def initialize(name, doc, &block)
        @name = name
        @doc = doc

        @tests = {}

        @@suites[name] = self

        raise "You must pass a block to the Test" unless block_given?
        instance_eval(&block)
    end

    # Define a new type of test on this suite.
    def newtest(name, doc, &block)
        @tests[name] = doc

        if @@tests[name]
            raise "Test names must be unique; cannot redefine %s" % name
        end

        @@tests[name] = @name

        meta_def(name, &block)
    end

    # Run the actual test.
    def run(test)
        unless doc = @tests[test]
            raise "Suite %s only supports tests %s; not %s" % [@name, @tests.keys.collect { |k| k.to_s }.join(","), test]
        end
        puts "Running %s %s test" % [@name, test]

        prepare() if respond_to?(:prepare)

        if $options[:pause]
            puts "Hit any key to continue"
            $stdin.readline
            puts "Continuing with test"
        end

        if $options[:fork] > 0
            @forking = true
            $options[:fork].times {
                if pid = fork
                    $pids << pid
                else
                    break
                end
            }
        end

        $options[:repeat].times do |i|
            @count = i
            if forked?
                msg = doc + " in PID %s" % Process.pid
            else
                msg = doc
            end
            Puppet::Util.benchmark(:notice, msg) do
                begin
                    send(test)
                rescue => detail
                    puts detail.backtrace if Puppet[:trace]
                    Puppet.err "%s failed: %s" % [@name, detail.to_s]
                end
            end
        end
    end

    # What tests are available on this suite?
    def tests
        @tests.keys
    end
end

Suite.new :parser, "Manifest parsing" do
    newtest :parse, "Parsed files" do
        @parser = Puppet::Parser::Parser.new(:environment => Puppet[:environment])
        @parser.file = Puppet[:manifest]
        @parser.parse
    end
end

Suite.new :local_catalog, "Local catalog handling" do
    def prepare
        @node = Puppet::Node.find($options[:nodes][0])
    end

    newtest :compile, "Compiled catalog" do
        Puppet::Resource::Catalog.find(@node)
    end
end

Suite.new :resource_type, "Managing resource types" do
    newtest :find, "Find a type" do
        Puppet::Resource::Type.terminus_class = :parser
        ARGV.each do |name|
            json = Puppet::Resource::Type.find(name).to_pson
            data = PSON.parse(json)
            p Puppet::Resource::Type.from_data_hash(data)
        end
    end

    newtest :search_types, "Find all types" do
        Puppet::Resource::Type.terminus_class = :rest
        result = Puppet::Resource::Type.search("*")
        result.each { |r| p r }
    end

    newtest :restful_type, "Find a type and return it via REST" do
        Puppet::Resource::Type.terminus_class = :rest
        ARGV.each do |name|
            p Puppet::Resource::Type.find(name)
        end
    end
end

Suite.new :remote_catalog, "Remote catalog handling" do
    def prepare
        $args[:cache] = false
        # Create a config client and pull the config down
        @client = Puppet::Network::Client.master.new($args)
        unless @client.read_cert
            fail "Could not read client certificate"
        end

        if tmp = Puppet::Node::Facts.find($options[:nodes][0])
            @facts = tmp.values
        else
            raise "Could not find facts for %s" % $optins[:nodes][0]
        end

        if host = $options[:fqdn]
            @facts["fqdn"] = host
            @facts["hostname"] = host.sub(/\..+/, '')
            @facts["domain"] = host.sub(/^[^.]+\./, '')
        end

        @facts = YAML.dump(@facts)
    end

    newtest :getconfig, "Compiled catalog" do
        @client.driver.getconfig(@facts, "yaml")
    end

    # This test will always force a false answer.
    newtest :fresh, "Checked freshness" do
        @client.driver.freshness
    end
end

Suite.new :file, "File interactions" do
    def prepare
        unless $options[:file]
            fail "You must specify a file (using --file <file>) to interact with on the server"
        end
        @client = Puppet::Network::Client.file.new($args)
        unless @client.read_cert
            fail "Could not read client certificate"
        end
    end

    newtest :describe, "Described file" do
        @client.describe($options[:file], :ignore)
    end

    newtest :retrieve, "Retrieved file" do
        @client.retrieve($options[:file], :ignore)
    end
end

Suite.new :filebucket, "Filebucket interactions" do
    def prepare
        require 'tempfile'
        @client = Puppet::FileBucket::Dipper.new($args)
    end

    newtest :backup, "Backed up file" do
        Tempfile.open("bucket_testing") do |f|
            f.print rand(1024)
            f.close
            @client.backup(f.path)
        end
    end
end

Suite.new :report, "Reports interactions" do
    def prepare
        Puppet::Transaction::Report.terminus_class = :rest
    end

    newtest :empty, "send empty report" do
        report = Puppet::Transaction::Report.new
        report.time = Time.now
        report.save
    end

    newtest :fake, "send fake report" do
        report = Puppet::Transaction::Report.new

        resourcemetrics = {
            :total => 12,
            :out_of_sync => 20,
            :applied => 45,
            :skipped => 1,
            :restarted => 23,
            :failed_restarts => 1,
            :scheduled => 10
        }
        report.newmetric(:resources, resourcemetrics)

        timemetrics = {
            :resource1 => 10,
            :resource2 => 50,
            :resource3 => 40,
            :resource4 => 20,
        }
        report.newmetric(:times, timemetrics)

        report.newmetric(:changes,
            :total => 20
        )

        report.time = Time.now
        report.save
    end
end


$cmdargs = [
    [ "--compile",  "-c", GetoptLong::NO_ARGUMENT       ],
    [ "--describe",       GetoptLong::REQUIRED_ARGUMENT ],
    [ "--retrieve", "-R", GetoptLong::REQUIRED_ARGUMENT ],
    [ "--fork",           GetoptLong::REQUIRED_ARGUMENT ],
    [ "--fqdn",     "-F", GetoptLong::REQUIRED_ARGUMENT ],
    [ "--suite",    "-s", GetoptLong::REQUIRED_ARGUMENT ],
    [ "--test",     "-t", GetoptLong::REQUIRED_ARGUMENT ],
    [ "--pause",    "-p", GetoptLong::NO_ARGUMENT       ],
    [ "--repeat",   "-r", GetoptLong::REQUIRED_ARGUMENT ],
    [ "--node",     "-n", GetoptLong::REQUIRED_ARGUMENT ],
    [ "--debug",    "-d", GetoptLong::NO_ARGUMENT       ],
    [ "--help",     "-h", GetoptLong::NO_ARGUMENT       ],
    [ "--list",     "-l", GetoptLong::NO_ARGUMENT       ],
    [ "--verbose",  "-v", GetoptLong::NO_ARGUMENT       ],
    [ "--version",  "-V", GetoptLong::NO_ARGUMENT       ],
]

# Add all of the config parameters as valid $options.
Puppet.settings.addargs($cmdargs)
Puppet::Util::Log.newdestination(:console)

Puppet::Node.terminus_class = :plain
Puppet::Node.cache_class = :yaml
Puppet::Node::Facts.terminus_class = :facter
Puppet::Node::Facts.cache_class = :yaml

result = GetoptLong.new(*$cmdargs)

$args = {}

$options = {:repeat => 1, :fork => 0, :pause => false, :nodes => []}

begin
    explicit_waitforcert = false
    result.each { |opt,arg|
        case opt
            # First check to see if the argument is a valid setting;
            # if so, set it.
            when "--compile"
                $options[:suite] = :configuration
                $options[:test] = :compile
            when "--retrieve"
                $options[:suite] = :file
                $options[:test] = :retrieve
                $options[:file] = arg
            when "--fork"
                begin
                    $options[:fork] = Integer(arg)
                rescue => detail
                    $stderr.puts "The argument to 'fork' must be an integer"
                    exit(14)
                end
            when "--describe"
                $options[:suite] = :file
                $options[:test] = :describe
                $options[:file] = arg
            when "--fqdn"
                $options[:fqdn] = arg
            when "--repeat"
                $options[:repeat] = Integer(arg)
            when "--help"
                if Puppet.features.usage?
                    RDoc::usage && exit
                else
                    puts "No help available unless you have RDoc::usage installed"
                    exit
                end
            when "--version"
                puts "%s" % Puppet.version
                exit
            when "--verbose"
                Puppet::Util::Log.level = :info
                Puppet::Util::Log.newdestination(:console)
            when "--debug"
                Puppet::Util::Log.level = :debug
                Puppet::Util::Log.newdestination(:console)
            when "--suite"
                $options[:suite] = arg.intern
            when "--test"
                $options[:test] = arg.intern
            when "--file"
                $options[:file] = arg
            when "--pause"
                $options[:pause] = true
            when "--node"
                $options[:nodes] << arg
            when "--list"
                Suite.suites.sort { |a,b| a.to_s <=> b.to_s }.each do |suite_name|
                    suite = Suite[suite_name]
                    tests = suite.tests.sort { |a,b| a.to_s <=> b.to_s }.join(", ")
                    puts "%20s: %s" % [suite_name, tests]
                end
                exit(0)
            else
                Puppet.settings.handlearg(opt, arg)
        end
    }
rescue GetoptLong::InvalidOption => detail
    $stderr.puts detail
    $stderr.puts "Try '#{$0} --help'"
    exit(1)
end

# Now parse the config
Puppet.initialize_settings

$options[:nodes] << Puppet.settings[:certname] if $options[:nodes].empty?

$args[:Server] = Puppet[:server]

unless $options[:test]
    $options[:suite] = :remote_catalog
    $options[:test] = :getconfig
end

unless $options[:test]
    raise "A suite was specified without a test"
end

$pids = []

Suite.run($options[:test])

if $options[:fork] > 0
    Process.waitall
end
